iT邦幫忙

2026 iThome 鐵人賽

DAY 1
1
Modern Web

Ash framework, Elixir 的商業邏輯框架系列 第 1

Ash 要解決的是什麼

  • 分享至 

  • xImage
  •  

系統累積久了常常遇到的商業邏輯整理問題

當一個 Phoenix 專案運行一陣子之後, 可能有 LiveView 的網頁頁面, 提供給第三方廠商或是手機 App 的 JSON API, 還有一個後台管理介面。

假如我們要實作一些非常基本的邏輯在我們假想的 CMS 系統:

只有編輯可以發布貼文, 或是, 內文要有內容才能發佈。

邏輯本身沒什麼, 真正花時間的是要想這些規定要寫在哪邊?
而且當 app 有介面的時候 (網頁, 手機 App, 後台管理介面), 到底會有幾個地方需要知道某個規定?

讓我們比較一下使用基本 Ecto/Phoneix 跟 Ash 的寫法

功能: 一個有 title 跟 body 的 Post, 可以建草稿, 可以發布,
只有編輯可以發布, 內文要有內容才能發佈。

先用一般 Phoenix 專案的寫法寫一次, 再用 Ash 寫一次。

純 Phoenix + Ecto 的寫法

migration, 手寫:

# priv/repo/migrations/20260915000000_create_posts.exs
defmodule DemoCms.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def change do
    create table(:posts, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :title, :string, null: false
      add :body, :text
      add :status, :string, null: false, default: "draft"
      add :published_at, :utc_datetime
      timestamps(type: :utc_datetime)
    end
  end
end

schema 跟它的 changeset:

# lib/demo_cms/content/post.ex
defmodule DemoCms.Content.Post do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  schema "posts" do
    field :title, :string
    field :body, :string
    field :status, Ecto.Enum, values: [:draft, :published], default: :draft
    field :published_at, :utc_datetime
    timestamps(type: :utc_datetime)
  end

  def draft_changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :body])
    |> validate_required([:title])
  end

  def publish_changeset(post) do
    post
    |> change(status: :published, published_at: DateTime.utc_now(:second))
    |> validate_required([:body], message: "cannot publish an empty post")
  end
end

context, 「只有編輯可以發布」這條規定住在這裡:

# lib/demo_cms/content.ex
defmodule DemoCms.Content do
  import Ecto.Query
  alias DemoCms.Repo
  alias DemoCms.Content.Post

  def list_published_posts do
    Repo.all(from p in Post, where: p.status == :published, order_by: [desc: p.published_at])
  end

  def get_post!(id), do: Repo.get!(Post, id)

  def create_draft(attrs) do
    %Post{}
    |> Post.draft_changeset(attrs)
    |> Repo.insert()
  end

  def publish_post(%{role: :editor}, %Post{} = post) do
    post
    |> Post.publish_changeset()
    |> Repo.update()
  end

  def publish_post(_user, _post), do: {:error, :unauthorized}
end

兩個呼叫端, 一個 LiveView, 一個 JSON controller:

# lib/demo_cms_web/live/post_live/show.ex
def handle_event("publish", _params, socket) do
  case Content.publish_post(socket.assigns.current_user, socket.assigns.post) do
    {:ok, post} -> {:noreply, assign(socket, post: post)}
    {:error, :unauthorized} -> {:noreply, put_flash(socket, :error, "Not allowed")}
    {:error, changeset} -> {:noreply, put_flash(socket, :error, error_text(changeset))}
  end
end
# lib/demo_cms_web/controllers/api/post_controller.ex
def publish(conn, %{"id" => id}) do
  post = Content.get_post!(id)

  case Content.publish_post(conn.assigns.current_user, post) do
    {:ok, post} -> json(conn, %{data: post_json(post)})
    {:error, :unauthorized} -> send_resp(conn, 403, "")
    {:error, changeset} -> conn |> put_status(422) |> json(%{errors: errors_json(changeset)})
  end
end

Ash 的寫法

resource。這一個檔案取代了 schema、兩個 changeset, 還有 context 的主要內容:

# lib/demo_cms/content/post.ex
defmodule DemoCms.Content.Post do
  use Ash.Resource,
    domain: DemoCms.Content,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "posts"
    repo DemoCms.Repo
  end

  actions do
    defaults [:read]

    read :published do
      filter expr(status == :published)
      prepare build(sort: [published_at: :desc])
    end

    create :create_draft do
      accept [:title, :body]
    end

    update :publish do
      validate present(:body), message: "cannot publish an empty post"
      change set_attribute(:status, :published)
      change set_attribute(:published_at, &DateTime.utc_now/0)
    end
  end

  policies do
    policy action_type(:read) do
      authorize_if always()
    end

    policy action(:create_draft) do
      authorize_if actor_present()
    end

    policy action(:publish) do
      authorize_if actor_attribute_equals(:role, :editor)
    end
  end

  attributes do
    uuid_primary_key :id
    attribute :title, :string, allow_nil?: false, public?: true
    attribute :body, :string, public?: true
    attribute :status, :atom, constraints: [one_of: [:draft, :published]], default: :draft
    attribute :published_at, :utc_datetime
    timestamps()
  end
end

domain, 對外的函式名稱放在這裡:

# lib/demo_cms/content.ex
defmodule DemoCms.Content do
  use Ash.Domain

  resources do
    resource DemoCms.Content.Post do
      define :list_published_posts, action: :published
      define :get_post, action: :read, get_by: [:id]
      define :create_draft, action: :create_draft
      define :publish, action: :publish
    end
  end
end

migration 不用手寫, 從 attributes 區塊產生:

mix ash.codegen create_posts

一樣的兩個呼叫端:

# LiveView
def handle_event("publish", _params, socket) do
  case Content.publish(socket.assigns.post, actor: socket.assigns.current_user) do
    {:ok, post} -> {:noreply, assign(socket, post: post)}
    {:error, error} -> {:noreply, put_flash(socket, :error, Exception.message(error))}
  end
end
# JSON controller
def publish(conn, %{"id" => id}) do
  post = Content.get_post!(id)

  case Content.publish(post, actor: conn.assigns.current_user) do
    {:ok, post} -> json(conn, %{data: post_json(post)})
    {:error, %Ash.Error.Forbidden{}} -> send_resp(conn, 403, "")
    {:error, %Ash.Error.Invalid{} = error} -> conn |> put_status(422) |> json(%{errors: errors_json(error)})
  end
end

比較一下

「內文要有內容」寫在哪

  • Phoenix + Ecto: publish_changeset/1
  • Ash: :publish 這個 action

「只有編輯可以」寫在哪

  • Phoenix + Ecto: publish_post/2 的 pattern match
  • Ash: :publish 的 policy

這兩條規定真正生效的地方

  • Phoenix + Ecto: 看誰記得呼叫 publish_post/2
  • Ash: :publish 裡面; 沒有其他方式可以執行它

migration

  • Phoenix + Ecto: 手寫
  • Ash: 從 attributes 產生

在 Phoenix + Ecto 的版本如果後台加了一個批次發布的按鈕, 然後用 Repo.update_all 來做, 如果沒有特別寫的話兩條規定都會被跳過, 沒人發現

Ash 的話, 用 Ash.bulk_update:publish 這個 action, 每一筆都會跑同樣的 validation 跟同樣的 policy, 因為規定是掛在 action 上, 而不是掛在一個大家應該要記得但是容易忘記呼叫的函式上。

這些差別在累積久了之後, 隨著功能越來越多. 如這個系列要做的 CMS, 公開頁面, 編輯後台, headless API, 背景排程等等,有規章小心的開發也會開始累積例外, 漸漸的讓系統不好維護擴充

當然還是有代價

  • 必須要使用專用的 DSL validate present(:body)actor_attribute_equalsdefine ... get_by: 剛開始會有種在 Elixir 裡面學另一個小語言的感覺.
  • 除錯要穿過 framework。:publish 回傳 Forbidden 的時候, 你要知道怎麼問它「是哪一條 policy 說不行」

下一篇
建立 Ash + Phoenix 專案與相關 task 工具
系列文
Ash framework, Elixir 的商業邏輯框架2
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言